ScalaCheckで日付/時刻のテストをする
はじめに
ScalaCheckはプロパティベーステストを記述するテスティングライブラリです。ScalaCheckでは目的に応じたGenを定義することで様々なテストを記述できます。今回はScalaCheckで日付、時刻を入力とする関数をテストを記述してみました。
テスト対象のクラス
下記のように日付、時刻に応じて異なる割引(Discount)を返す関数(Discount#apply)をテストします。
import java.time.temporal.ChronoUnit import java.time.{DayOfWeek, ZonedDateTime} sealed trait Discount object Discount { def apply(date: ZonedDateTime): Option[Discount] = date.getDayOfWeek match { //水曜日は学生割引 case DayOfWeek.WEDNESDAY => Some(StudentsDay) //金曜日の19時以降は割引 case DayOfWeek.FRIDAY if date.isAfter(date.withHour(19).truncatedTo(ChronoUnit.HOURS)) => Some(TGIF) //以上!! case _ => None } final case object TGIF extends Discount final case object StudentsDay extends Discount }
日付/時刻生成の方針
以下のような方針で日付、時刻を生成してみます。
- 指定する期間に含まれる日付および時刻を表すZonedDateTimeを生成するGenを作る
- 詳細として以下を指定できる
- 生成されるZonedDateTimeの曜日を指定できる(例: 月曜日)
- 生成されるZonedDateTimeの開始時刻、終了時刻を指定できる(例: 10時〜17時)
コード
Term
まず指定期間の日付を生成するGenを以下のように定義します。実際には開始と終了の間の日数の範囲で整数を生成して、開始日に加えることで日付に変換します。
import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import java.time.{DayOfWeek, LocalDate, ZoneId, ZonedDateTime} import DateTimeGen.ZonedDateTimeGenOps import org.scalacheck.Gen import cats.implicits._ import scala.util.Try import eu.timepit.refined.auto._ final case class Term(from: ZonedDateTime, end: ZonedDateTime) { //期間内の任意の日付を生成する lazy val anyDate: Gen[ZonedDateTime] = { val days = ChronoUnit.DAYS.between(from, end) Gen.choose(0, days).map(from.truncatedTo(ChronoUnit.DAYS).plusDays) } //特定の曜日の日付を生成する //anyDateで生成した日付から、指定する曜日の分ずらす def dayOfWeek(d: DayOfWeek): Gen[ZonedDateTime] = anyDate.map(date => date.plusDays((d.getValue - date.getDayOfWeek.getValue).toLong)) .betweenHM((0, 0), (23, 59)) } object Term { private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("y/M/d") def apply(from: String, end: String): Try[Term] = { def tryParse(s: String): Try[ZonedDateTime] = Try(LocalDate.parse(s, dateFormatter).atStartOfDay(ZoneId.of("Asia/Tokyo"))) (tryParse(from), tryParse(end)).mapN(apply) } }
DateTimeGen
特定時刻を生成するためGenと時刻の指定に必要なエイリアスを以下のように定義します。時分はRefinedで値を制限しています。曜日を指定する場合は#anyDate
で生成した日付にさらにオフセットを加えて曜日をずらします。
import java.time.ZonedDateTime import java.time.temporal.ChronoUnit import eu.timepit.refined.W import eu.timepit.refined.api.Refined import eu.timepit.refined.numeric.Interval import org.scalacheck.Gen import scala.concurrent.duration._ object DateTimeGen { //時 type Hour = Int Refined Interval.Closed[W.`0`.T, W.`23`.T] //分 type Minute = Int Refined Interval.Closed[W.`0`.T, W.`59`.T] //時刻 type HM = (Hour, Minute) implicit class HMOps(hm: HM) { def toDuration: Duration = hm match { case (h, m) => h.value.hours + m.value.minutes } } implicit class ZonedDateTimeGenOps(gen: Gen[ZonedDateTime]) { //特定の時間帯を生成する def betweenHM(from: HM, end: HM): Gen[ZonedDateTime] = gen.flatMap(date => Gen .choose( from.toDuration.toSeconds, end.toDuration.toSeconds ).map(date.truncatedTo(ChronoUnit.DAYS).plusSeconds)) } }
プロパティ例
上記のGenとヘルパーを使っていくつかのプロパティを記述してみます。この例にはGenの動作を確かめるため失敗するケースも含めています。
import java.time.DayOfWeek import DateTimeGen.ZonedDateTimeGenOps import eu.timepit.refined.auto._ import org.scalacheck.Prop._ import org.scalacheck.Properties import cats.implicits._ import cats.kernel.Eq object DiscountSpec extends Properties("Discount") { implicit val dayOfWeekEq:Eq[Option[Discount]] = Eq.fromUniversalEquals val term = Term("2020/01/01", "2021/03/31").get property("No discount except Friday or Wednesday") = forAll(term.anyDate .suchThat(d => DayOfWeek.FRIDAY != d.getDayOfWeek && DayOfWeek.WEDNESDAY != d.getDayOfWeek )) { date => Discount(date) === None } //+ Discount.No discount except Friday or Wednesday: OK, passed 100 tests. property("Student's day") = forAll(term.dayOfWeek(DayOfWeek.WEDNESDAY)) { time => Discount(time) === Some(Discount.StudentsDay) } //+ Discount.Student's day: OK, passed 100 tests. property("TGIF") = forAll(term.dayOfWeek(DayOfWeek.FRIDAY).betweenHM((19, 0), (23, 59))) { time => Discount(time) === Some(Discount.TGIF) } //+ Discount.TGIF: OK, passed 100 tests. property("it isn't Friday night yet") = forAll(term.dayOfWeek(DayOfWeek.FRIDAY).betweenHM((18, 0), (23, 59))) { time => Discount(time) === Some(Discount.TGIF) } //failing seed for Discount.it isn't Friday night yet is r7fzITjgMXePgDi9VWvvSA53TsUZIovDgCMF8JFAJXJ= //! Discount.it isn't Friday night yet: Falsified after 11 passed tests. //> ARG_0: 2020-06-05T18:02:07+09:00[Asia/Tokyo] //Found 1 failing properties. }
やらなかったこと
上記のコードにたどり着く前に以下のようなfilterを使ったパターンも試してみましたが、filterで弾かれたケースはアサートの失敗としてカウントされてテスト自体が失敗するため今回のような実装になりました。
def dayOfWeek(d: DayOfWeek): Gen[ZonedDateTime] = anyDate.filter(date => date.getDayOfWeek == d)
まとめ
ScalaCheckで日付、時刻を生成するGenを定義しテストを書いてみました。